package afcp.alumni.search;

import java.beans.IntrospectionException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Set;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import afcp.alumni.model.base.MyEntity;
import afcp.alumni.model.db.Member;
import afcp.alumni.util.ReflectUtil;

import com.google.appengine.api.datastore.GeoPt;
import com.google.appengine.api.search.DeleteException;
import com.google.appengine.api.search.Document;
import com.google.appengine.api.search.Field;
import com.google.appengine.api.search.GeoPoint;
import com.google.appengine.api.search.GetIndexesRequest;
import com.google.appengine.api.search.GetRequest;
import com.google.appengine.api.search.GetResponse;
import com.google.appengine.api.search.Index;
import com.google.appengine.api.search.IndexSpec;
import com.google.appengine.api.search.PutException;
import com.google.appengine.api.search.Query;
import com.google.appengine.api.search.QueryOptions;
import com.google.appengine.api.search.Results;
import com.google.appengine.api.search.ScoredDocument;
import com.google.appengine.api.search.SearchException;
import com.google.appengine.api.search.SearchService;
import com.google.appengine.api.search.SearchServiceFactory;
import com.google.appengine.api.search.StatusCode;

/**
 * 
 * @author Antoine
 * 
 */
public class DaoSearchAPI<T> {
	
	private static final Log logger = LogFactory.getLog(DaoSearchAPI.class);
	
	private static final Class<Indexable> indexableClass = Indexable.class;
	private static final Class<IndexFilter> filterClass = IndexFilter.class;
	
	private List<IndexableGetter> getters = null;
	private List<FilterGetter> filters = null;

//	private static DaoSearchAPI<?> instance = null;

//	public static <U> DaoSearchAPI<U> getInstance(Class<U> entityClass) {
//		if (instance == null) {
//			instance = new DaoSearchAPI<U>();
//		}
//		return instance;
//	}
	private final Class<T> entityClass;
	protected String entityClassName;
	
	public DaoSearchAPI(Class<T> entityClass) {
		this.entityClass = entityClass;
		entityClassName = entityClass.getSimpleName();
		// null if entity is not searchable (no annotation @Searchable on class declaration)
		getters = initIndexableGetters();
		filters = initFilterGetters();
	}
	
	private List<IndexableGetter> initIndexableGetters() {
		if (entityClass.isAnnotationPresent(indexableClass)) {
			List<IndexableGetter> result = new LinkedList<>();

			for (java.lang.reflect.Field field : ReflectUtil.getFieldsWithAnnotation(
					entityClass, indexableClass)) {
				String name = field.getName();
				try {
					result.add(new IndexableGetter(name, ReflectUtil.findGetter(entityClass,
							name), field.getAnnotation(indexableClass)
							.locale()));
				} catch (NoSuchFieldException | NoSuchMethodException
						| IntrospectionException e) {
					logger.error("Could not find getter method for field "
							+ name, e);
				}
			}

			return result;
		} else {
			return null;
		}
	}
	
	private List<FilterGetter> initFilterGetters() {
		if (entityClass.isAnnotationPresent(indexableClass)) {
			List<FilterGetter> result = new LinkedList<>();

			for (java.lang.reflect.Field field : ReflectUtil.getFieldsWithAnnotation(
					entityClass, filterClass)) {
				String name = field.getName();
				try {
					result.add(new FilterGetter(name, ReflectUtil.findGetter(entityClass,
							name), field.getAnnotation(filterClass)
							.value()));
				} catch (NoSuchFieldException | NoSuchMethodException
						| IntrospectionException e) {
					logger.error("Could not find getter method for field "
							+ name, e);
				}
			}

			return result;
		} else {
			return null;
		}
	}

	private static class IndexableGetter extends Getter {
		public Indexable.Locale locale;
		public IndexableGetter(String field, Method getter, Indexable.Locale locale) {
			super(field, getter);
			this.locale = locale;
		}
	}
	
	private static class FilterGetter extends Getter {
		public Set<String> criteria;
		public FilterGetter(String field, Method getter, String[] criteria) {
			super(field, getter);
			this.criteria = new HashSet<>(criteria.length);
			Collections.addAll(this.criteria, criteria);
		}
	}
	
	private static class Getter {
		public String field;
		public Method getter;
		public Getter(String field, Method getter) {
			this.field = field;
			this.getter = getter;
		}
	}

	protected Index getIndex(MyEntity entity) {
		return getIndex(entity.getClass().getSimpleName());
	}
	
	private static final String INDEX_NAME_SUFFIX = "Index";
	
	protected Index getIndex(String className) {
		IndexSpec indexSpec = IndexSpec.newBuilder().setName(className + INDEX_NAME_SUFFIX).build();
		return SearchServiceFactory.getSearchService().getIndex(indexSpec);
	}
	
	// TODO optimiser en ne construisant qu'un champ pour la search API avec
	// chaque champ de MyEntity séparé d'un espace. ça revient au même pour la
	// recherche en principe.
	// TODO se renseigner pour la gestion des synonymes, approximations... à
	// gérer niveau applicatif ?
	private Document getDocument(MyEntity entity) {
		if (getters == null) {
			return null;
		}
		Document.Builder builder = Document.newBuilder().setId(
				entity.getId());
		
		for (IndexableGetter getter : getters) {
			try {
				Object value = getter.getter.invoke(entity);
				if (value != null) {
					builder.addField(field(getter.field,value, getter.locale));
				}
			} catch (IllegalAccessException | IllegalArgumentException
					| InvocationTargetException e) {
				logger.error("Could not execute getter method of field "
						+ getter.field + " for entity " + entity
						+ ". method: " + getter.getter, e);
			}
		}
		
		return builder.build();
	}
//	private Field.Builder field(String name, String value) {
//		return Field.newBuilder().setName(name).setText(value);
//	}
	private static Field.Builder field(String name, Object value,
			Indexable.Locale locale) {
		Field.Builder builder = Field.newBuilder().setName(name);
		switch (locale) {
		case FR:
			builder.setLocale(Locale.FRANCE);
			break;
		case EN:
			builder.setLocale(Locale.ENGLISH);
			break;
		case ZH:
			builder.setLocale(Locale.CHINA);
			break;
		case NONE: // do nothing
			break;
		}
		if (value instanceof String) {
			builder.setText((String) value);
		} else if (value instanceof Number) {
			builder.setNumber(((Number) value).doubleValue());
		} else if (value instanceof Date) {
			builder.setDate((Date) value);
		} else if (value instanceof GeoPoint) {
			builder.setGeoPoint((GeoPoint) value);
		} else if (value instanceof GeoPt) {
			GeoPt pt = (GeoPt) value;
			builder.setGeoPoint(new GeoPoint(pt.getLatitude(), pt
					.getLongitude()));
		} else {
			builder.setText(value.toString());
		}
		return builder;
	}
	
	private boolean eligibleEntity(MyEntity entity) {
		if (getters == null) {
			return false;
		}
		
		for (FilterGetter filter : filters) {
			try {
				Object value = filter.getter.invoke(entity);
				if (value != null) {
					if (value instanceof Collection) {
						if (!oneContains(filter.criteria, (Collection<?>) value)) {
							return false;
						}
					} else if (!filter.criteria.contains(value.toString())) {
						return false;
					}
				}
			} catch (IllegalAccessException | IllegalArgumentException
					| InvocationTargetException e) {
				logger.error("Could not execute getter method of field "
						+ filter.field + " for entity " + entity
						+ ". method: " + filter.getter, e);
			}
		}
		
		return true;
	}
	private static boolean oneContains(Set<String> criteria, Collection<?> value) {
		for (Object subval : value) {
			if (criteria.contains(subval.toString())) {
				return true;
			}
		}
		return false;
	}

	public boolean add(MyEntity entity) {
		if (getters == null) {
			// If document is not indexable, do nothing without warning.
			// It allows generic calls to this method, delegating checking
			// whether the entity is indexable.
			return true;
		}
		if (!eligibleEntity(entity)) {
			// If one of the filter criteria does not match with the entity
			// attributes, this entity is not indexed.
			return true;
		}
		
		try {
			Index index = getIndex(entity);
//			Document document = entity.getDocument();
			Document document = getDocument(entity);
			if (document == null) {
				return false;
			}
			try {
				index.put(document);
				return true;
			} catch (PutException e) {
			    if (StatusCode.TRANSIENT_ERROR.equals(e.getOperationResult().getCode())) {
					logger.info("Failed indexing; retrying...");
					try {
						index.put(document);
					} catch (PutException e1) {
						return false;
					}
					return true;
			    }
			}
		} catch (Exception e) {
			logger.error("Could not put in index the entity with id: " + entity.getId(), e);
		}

		return false;
	}
	
	/**
	 * Contrainte d'utilisation : toutes les entités doivent être de la même classe ! La classe
	 * de la première entité sera utilisée pour déterminer l'index dans lequel les entités seront stockées.
	 * @param entities
	 * @return
	 */
	public boolean add(List<? extends MyEntity> entities) {
		if (getters == null) {
			// If document is not indexable, do nothing without warning.
			// It allows generic calls to this method, delegating checking
			// whether the entity is indexable.
			return true;
		}
		
		// HashSet is necessary to deduplicate entities. 2 entities with the
		// same id but different field names is not accepted by the search API.
		Set<Document> documents = new HashSet<>();
		try {
			Index index = null;
			boolean allAdded = true;
			for (MyEntity entity : entities) {
				if (eligibleEntity(entity)) {
					if (index == null) {
						index = getIndex(entity);
					}
					Document document = getDocument(entity);
					if (document != null) {
						documents.add(document);
					} else {
						allAdded = false;
					}
				}
			}
			try {
				logger.info("Add documents to the index");
				if (index != null) {
					index.put(documents);
				}
				return allAdded;
			} catch (PutException e) {
			    if (StatusCode.TRANSIENT_ERROR.equals(e.getOperationResult().getCode())) {
					logger.info("Failed indexing; retrying...");
					try {
						index.put(documents);
					} catch (PutException e1) {
						logger.info("Failed indexing again.");
						return false;
					}
					return allAdded;
			    }
			}
		} catch (Exception e) {
			logger.error("Could not put in index the entities.", e);
		}

		return false;
	}
	
	public boolean remove(String id) {
		if (getters == null) {
			// If document is not indexable, do nothing without warning.
			// It allows generic calls to this method, delegating checking
			// whether the entity is indexable.
			return true;
		}
		try {
			Index index = getIndex(entityClassName);
			try {
				index.delete(id);
				return true;
			} catch (DeleteException e) {
			    if (StatusCode.TRANSIENT_ERROR.equals(e.getOperationResult().getCode())) {
					index.delete(id);
					return true;
			    }
			}
		} catch (Exception e) {
			logger.error("Could not delete from index the entity with id: " + id, e);
		}
		return false;
	}
	
	/**
	 * Contrainte d'utilisation : toutes les entités doivent être de la même classe ! La classe
	 * de la première entité sera utilisée pour déterminer l'index dans lequel les entités seront supprimées.
	 * @param entities
	 * @return
	 */
	public /*<U extends SAPIEntity>*/ boolean remove(List<String> ids) {
		if (getters == null) {
			// If document is not indexable, do nothing without warning.
			// It allows generic calls to this method, delegating checking
			// whether the entity is indexable.
			return true;
		}
		List<String> documentIds = new LinkedList<>();
		try {
			if (ids.isEmpty()) {
				return true;
			}
			Index index = getIndex(entityClassName);
			for (String id : ids) {
//				if (index == null) {
//					index = getIndex(entity);
//				}
				documentIds.add(id);
			}
			try {
				index.delete(documentIds);
				return true;
			} catch (DeleteException e) {
			    if (StatusCode.TRANSIENT_ERROR.equals(e.getOperationResult().getCode())) {
					index.delete(documentIds);
					return true;
			    }
			}
		} catch (Exception e) {
			logger.error("Could not delete from index the entities.", e);
		}

		return false;
	}
	
	public boolean clearIndexOfThisClass() {
		if (getters == null) {
			// If document is not indexable, do nothing without warning.
			// It allows generic calls to this method, delegating checking
			// whether the entity is indexable.
			return true;
		}
		return clearIndex(getIndex(entityClassName));
	}
	public boolean clearIndexOfKindName(String kindName) {
		return clearIndex(getIndex(kindName));
	}
	
	public boolean clearAllIndexes() {

		// Get the SearchService for the default namespace
		SearchService searchService = SearchServiceFactory.getSearchService();

		// Get the first page of indexes available and retrieve schemas
		GetResponse<Index> indexResponse = searchService
				.getIndexes(GetIndexesRequest.newBuilder()
						.setSchemaFetched(false)
						.build());
		// TODO essayer de supprimer setSchemaFetched(true), ou de le passer à
		// false

		for (Index index : indexResponse) {
			if (!clearIndex(index)) {
				return false;
			}
		}

		return true;
	}
	
	private boolean clearIndex(Index index) {
		try {
		    // looping because getRange by default returns up to 100 documents at a time
		    while (true) {

				GetRequest request = GetRequest.newBuilder()
				// .setLimit(500)
				// .setStartId("some-id")
						.setReturningIdsOnly(true).build();

				List<String> documentIds = new LinkedList<>();

		        GetResponse<Document> response = index.getRange(request);
		        if (response.getResults().isEmpty()) {
		            break;
		        }

				for (Document document : response) {
					documentIds.add(document.getId());
				}

				logger.info("Deleting documents of index " + index.getName());
				index.delete(documentIds);
				
		    }
		    return true;
		} catch (RuntimeException e) {
			logger.error("Failed to delete documents", e);
			return false;
		}
	}

	// TODO ajouter une couche de préparation des données dont :
	// - reformatage des dates détectées dans la requête pour être au format yyyy-mm-dd
	// - gestion des synonymes (à reporter dans les données indexées de préférence)
	// - Amélioration du stemming (éviter les guillemets, découper par espace et
	// mettre un ~ devant chaque token)
	private String prepareQuery(String query) {
		return "~\"" + query + "\"";
	}
	
	// TODO voir si le "stemming" peut être intéressant (recherche des
	// variations : pluriels, conjugaisons...) avec l'opérateur ~ devant la
	// recherche.
	/**
	 * 
	 * @param queryString
	 *            the query used for this search. Example: <code>"robert"</code>
	 *            .
	 * @param className
	 *            the class name in which the search is processed. Example:
	 *            <p>
	 *            <code>entity.getClassObject().getSimpleName()</code>
	 *            </p>
	 *            Currently, only {@link Member} entities are indexed.
	 * @return a list of Ids of the documents found with this query. These ids
	 *         may be used to find the entities on the datastore.
	 */
	public SearchAPIResult search(String queryString, int page, int perPage) {
		
		queryString = prepareQuery(queryString);
		
		try {
			Index index = getIndex(entityClassName);
			Results<ScoredDocument> results = null;
			try {
			    // Query the index.
				Query query = Query
						.newBuilder()
						.setOptions(
								QueryOptions.newBuilder()
										.setReturningIdsOnly(true)
										.setLimit(perPage)
										.setOffset((page - 1) * perPage)
										.build())
						.build(queryString);
				logger.info("Run search in SAPI with query String: " + queryString);
				results = index.search(query);

			} catch (SearchException e) {
			    if (StatusCode.TRANSIENT_ERROR.equals(e.getOperationResult().getCode())) {
				    try {
						results = index.search(queryString);
					} catch (Exception e1) {
						logger.warn("Search \""+queryString+"\" failed!", e1);
					}
			    } else {
			    	throw e;
			    }
			}
			SearchAPIResult result = new SearchAPIResult();
			result.setTotalResults(results.getNumberFound());
		    for (ScoredDocument document : results) {
		    	result.addId(document.getId());
		    }
			return result;
		} catch (Exception e) {
			logger.error("Could not search in index the entity:" + entityClassName + " with query: " + queryString, e);
		}
		return null;
	}
}
